Skip to content

ci: prototype trusted external sweep dispatch / CI:试验可信外部扫描调度 - #2454

Merged
cquil11 merged 2 commits into
mainfrom
agent/trusted-external-sweeps-poc
Aug 2, 2026
Merged

ci: prototype trusted external sweep dispatch / CI:试验可信外部扫描调度#2454
cquil11 merged 2 commits into
mainfrom
agent/trusted-external-sweeps-poc

Conversation

@cquil11

@cquil11 cquil11 commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a control-plane-only pull_request_target workflow for external fork PRs
  • require a write-authorized maintainer to apply exactly one primary sweep label
  • pin the approved external head SHA and GitHub merge SHA, then dispatch e2e-tests.yml from main so benchmark jobs can receive repository secrets
  • keep ordinary external pull_request runs limited to changelog/reuse validation instead of launching GPU jobs with empty secrets
  • add regression tests and operator documentation

Security model

The pull_request_target job never checks out or executes PR code. It verifies the labeling actor's repository permission, refetches the PR, confirms the exact event head SHA is still current, and only then dispatches the trusted workflow. The benchmark workflow intentionally executes the approved merge revision with secrets; applying the primary label is therefore an explicit trust decision for that exact SHA. Later pushes require removing and re-adding the label.

PoC limitations

  • dispatched End-to-End Tests runs are not yet eligible for /reuse-sweep-run
  • the dispatched matrix fans out immediately and does not reproduce run-sweep.yml's canary-first sequencing
  • modifier labels must be added before the primary approval label

Validation


摘要

  • 为外部 fork PR 添加仅控制平面的 pull_request_target 工作流
  • 要求拥有写入权限的维护者应用且仅应用一个主扫描标签
  • 固定获批的外部 head SHA 和 GitHub merge SHA,再从 main 调度 e2e-tests.yml,使基准测试任务能够获取仓库密钥
  • 普通外部 pull_request 运行只执行 changelog/复用校验,不再使用空密钥启动 GPU 任务
  • 添加回归测试和维护者文档

安全模型

pull_request_target 任务不会检出或执行 PR 代码。它会验证添加标签者的仓库权限、重新获取 PR、确认事件中的 head SHA 仍是当前版本,然后才调度可信工作流。基准测试工作流会有意使用密钥执行获批的合并版本,因此应用主标签就是对该精确 SHA 的明确授权。后续推送必须移除并重新添加该标签。

PoC 限制

  • 调度的 End-to-End Tests 运行暂时不能用于 /reuse-sweep-run
  • 所选矩阵会立即展开,不包含 run-sweep.yml 的 canary-first 顺序
  • 修饰标签必须先于主批准标签添加

验证

Route maintainer-approved fork revisions through a main-branch workflow_dispatch so benchmark jobs receive required secrets while pinning the exact approved SHA.\n\n中文:通过 main 分支的 workflow_dispatch 调度维护者批准的 fork 提交,使基准测试任务在固定获批 SHA 的同时获取所需密钥。
Keep the behavioral external-fork gating coverage while dropping string-based checks that could pass despite broken workflow semantics.\n\n中文:保留外部 fork 门控的行为测试,删除即使工作流语义损坏也可能通过的字符串断言。
@cquil11
cquil11 marked this pull request as ready for review August 2, 2026 19:45
@cquil11
cquil11 requested a review from a team August 2, 2026 19:45
@cquil11
cquil11 merged commit ea73f7a into main Aug 2, 2026
7 checks passed
@cquil11
cquil11 deleted the agent/trusted-external-sweeps-poc branch August 2, 2026 19:45
@claude

claude Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @cquil11's task in 7m 43s —— View job


Review of PR #2454

  • Gather PR diff and repo context
  • Review trusted-external-sweep.yml (security-critical pull_request_target)
  • Review e2e-tests.yml, run-sweep.yml, test-changelog-gate.yml changes
  • Review test and docs changes
  • Post findings

Found 1 blocking issue and 1 warning, posted as inline comments. 🔴 e2e-tests.yml now defines 14 workflow_dispatch inputs, exceeding GitHub's hard limit of 10 — every dispatch (including the new trusted dispatcher and the existing claude.yml automation) will fail, and since this PR is merged, main is likely affected right now. 🟡 The dispatcher trusts pull.merge_commit_sha without verifying its parentage — the test merge is computed asynchronously and can be stale from a previously pushed (unapproved) head even when head.sha matches the approved SHA, so the parent check should be added before dispatching with secrets. The rest of the security model is sound: actor permission verification, revision re-fetch and pinning, single-primary-label enforcement, ref propagation to benchmark templates, and the internal-fork gate in run-sweep.yml all check out, as do the regression tests.

Comment on lines +43 to +77
changelog-base-ref:
description: "Trusted-dispatch mode: base SHA for process_changelog.py"
required: false
type: string
default: ""
changelog-head-ref:
description: "Trusted-dispatch mode: exact approved PR head SHA"
required: false
type: string
default: ""
trim-conc:
description: "Trim each configuration to its minimum concurrency"
required: false
type: boolean
default: false
all-evals:
description: "Expand evaluation selection to every fixed-sequence configuration"
required: false
type: boolean
default: false
evals-only:
description: "Suppress throughput and run only evaluations"
required: false
type: boolean
default: false
fail-fast:
description: "Cancel the rest of each matrix after its first failure"
required: false
type: boolean
default: false
pr-labels-json:
description: "Labels from the source PR for priority scoring"
required: false
type: string
default: "[]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 BLOCKING: The workflow_dispatch trigger now defines 14 inputs, but GitHub enforces a hard maximum of 10 inputs per workflow_dispatch event.

Why it matters: Dispatching this workflow fails with you may only define up to 10 'inputs' for a 'workflow_dispatch' event — which breaks the new trusted-external-sweep dispatcher, the existing claude.yml e2e automation, and manual runs from the Actions UI. Since this PR is already merged, e2e-tests.yml on main is likely un-dispatchable right now; a one-off manual dispatch will confirm.

Fix: The four new booleans are all derivable from pr-labels-json (that's exactly how trusted-external-sweep.yml computes them before dispatching): drop trim-conc, all-evals, evals-only, and fail-fast from the workflow_dispatch inputs, derive them inside get-jobs from the labels JSON (strategy blocks can read needs.get-jobs.outputs.*), and stop passing them from the dispatcher. That brings the count to 10. The workflow_call block has no 10-input limit and can keep them.

Fix this →

Comment on lines +86 to +89
if (!pull.merge_commit_sha) {
core.setFailed('GitHub has not produced a merge commit; resolve conflicts and re-add the label.');
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 WARNING: pull.merge_commit_sha is not guaranteed to be the merge of the validated pull.head.sha. GitHub computes the test merge asynchronously, and pulls.get can return a stale merge_commit_sha from a previous head while head.sha already reflects the current one.

Why it matters: An attacker can push a malicious commit (GitHub computes its test merge), then force-push back to the benign head. If the maintainer applies the label during the recompute window, every check here passes — pull.head.sha matches the event SHA — but merge_commit_sha still points at the merge of the malicious head, and that is the exact revision the dispatched workflow checks out and executes with repository secrets. This is the one gap in an otherwise solid SHA-pinning model.

Fix: verify the merge commit's parentage before dispatching:

const {data: mergeCommit} = await github.rest.git.getCommit({
  owner: context.repo.owner,
  repo: context.repo.repo,
  commit_sha: pull.merge_commit_sha,
});
if (!mergeCommit.parents.some((p) => p.sha === pull.head.sha)) {
  core.setFailed(
    `Merge commit ${pull.merge_commit_sha} does not have approved head ${pull.head.sha} as a parent; ` +
    'wait for GitHub to recompute mergeability, then remove and re-add the label.',
  );
  return;
}

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, I checked whether trusted-external-sweep.yml lacking a concurrency group lets rapid label add/remove race into an unintended dispatch — the script re-fetches the PR and re-verifies the exact head SHA and the single primary label before dispatching, so a stale/duplicate labeling event can't bypass the revision-specific approval; not a real gap.

Extended reasoning...

This run's own finding is significant: the trusted-dispatch changelog path resolves GENERATE_SWEEPS_PY_SCRIPT/MASTER_CONFIGS relative paths against the untrusted fork checkout rather than the pinned .ci-priority root, which undermines the exact isolation this PR is meant to establish for external-fork PRs. That alone warrants human review before merge, so I'm not approving. I additionally looked into the no-concurrency-group question on trusted-external-sweep.yml and ruled it out for the reason above.

Comment on lines +196 to +224
if [ -n "$CHANGELOG_BASE_REF" ] || [ -n "$CHANGELOG_HEAD_REF" ]; then
if [ -z "$CHANGELOG_BASE_REF" ] || [ -z "$CHANGELOG_HEAD_REF" ]; then
echo "Both changelog-base-ref and changelog-head-ref are required" >&2
exit 1
fi
CMD=(
uv run --no-project --with pydantic --with pyyaml --python 3.12
"${PRIORITY_ROOT}/utils/process_changelog.py"
--changelog-file "${GITHUB_WORKSPACE}/perf-changelog.yaml"
--base-ref "$CHANGELOG_BASE_REF"
--head-ref "$CHANGELOG_HEAD_REF"
)
if [ "$TRIM_CONC" = "true" ]; then
CMD+=(--trim-conc)
fi
if [ "$ALL_EVALS" = "true" ]; then
CMD+=(--all-evals)
fi
if [ "$EVALS_ONLY" = "true" ]; then
CMD+=(--evals-only)
fi
RAW_CONFIG_JSON=$("${CMD[@]}")
CONFIG_JSON=$(python3 -c 'import json,sys; data=json.load(sys.stdin); rows=[row for family in ("single_node","multi_node") for group in data.get(family,{}).values() for row in group]; rows.extend(row for family in ("evals","agentic_evals","multinode_evals") for row in data.get(family,[])); print(json.dumps(rows))' <<<"$RAW_CONFIG_JSON")
else
GENERATE_COMMAND="${{ inputs.generate-cli-command || github.event.inputs.generate-cli-command }}"
if [ -z "$GENERATE_COMMAND" ]; then
echo "generate-cli-command is required outside trusted changelog dispatch mode" >&2
exit 1
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The trusted-dispatch get-jobs step invokes the pinned .ci-priority/utils/process_changelog.py, but that script's internal subprocess.run/config-load calls use the repo-relative paths utils/matrix_logic/generate_sweep_configs.py and configs/{amd,nvidia}-master.yaml (from utils/constants.py) with no cwd= override, so they resolve against the step's actual working directory — the untrusted external-fork checkout at GITHUB_WORKSPACE — rather than the trusted .ci-priority copy. This lets an external contributor supply their own generate_sweep_configs.py and/or a doctored configs/*-master.yaml in their fork, defeating the config validation and matrix generation that the .ci-priority isolation was built to protect, with the resulting matrix flowing into downstream secrets: inherit benchmark jobs. Fix by passing cwd=PRIORITY_ROOT (or absolutizing GENERATE_SWEEPS_PY_SCRIPT/MASTER_CONFIGS) in utils/process_changelog.py, or by exporting PRIORITY_ROOT for the subprocess to use.

Extended reasoning...

The mechanism, confirmed against the actual source. utils/process_changelog.py (unmodified by this PR) imports GENERATE_SWEEPS_PY_SCRIPT = "utils/matrix_logic/generate_sweep_configs.py" and MASTER_CONFIGS = ["configs/amd-master.yaml", "configs/nvidia-master.yaml"] from utils/constants.py" — both repo-relative. Line 173 calls master_config = load_config_files(MASTER_CONFIGS)to validate that every changelog entry'sconfig-keysexist in the master config (raisingValueErrorotherwise, lines 104-119). Lines 211-233 and 248-274 then callsubprocess.run(["python3", GENERATE_SWEEPS_PY_SCRIPT, "test-config", ..., "--config-files", MASTER_CONFIGS, ...])with nocwd=argument, so both the script path and the config-file arguments resolve against whatever directory the parent process is running in.\n\nIne2e-tests.yml's get-jobsstep, that directory isGITHUB_WORKSPACE. In trusted-dispatch mode the 'Checkout code (ref)' step checks out inputs.ref (pull.merge_commit_sha, the *untrusted* external-fork merge revision) directly into GITHUB_WORKSPACEwith nopath:override, while the trusted tooling is separately checked out under.ci-priority(pinned togithub.workflow_sha). PRIORITY_ROOTis correctly set to.ci-priority, so process_changelog.pyitself — and its own top-level imports, which resolve viasys.path[0] = the script's own directory — do run the trusted copy. But the *relative* paths inside it (GENERATE_SWEEPS_PY_SCRIPT, MASTER_CONFIGS) are resolved by Python/the OS against the process cwd, which is GITHUB_WORKSPACE— the untrusted checkout — notPRIORITY_ROOT.\n\n**Why this defeats the isolation, not just 'runs PR code.'** trusted-external-sweep.yml always dispatches e2e-tests.ymlwithchangelog-base-ref/changelog-head-refset, so every approved external-fork run takes this branch unconditionally. Two independent attacks follow from the same root cause: (1) the external contributor can put arbitrary Python in their ownutils/matrix_logic/generate_sweep_configs.py, which then executes inside the trusted, secrets-context e2e-tests.ymlrun; and (2), even without any code execution, the contributor can simply edit their ownconfigs/amd-master.yaml/configs/nvidia-master.yamlto add a fabricated config entry (e.g. with an attacker-controlled containerimage) and reference that key from perf-changelog.yaml. Because load_config_files(MASTER_CONFIGS)reads the *fork's* master-config files, the fabricated key passes the 'must exist in master configs' check that.ci-priority's trusted process_changelog.pyis supposed to enforce, andgenerate_sweep_configs.py(also resolved from the fork) will happily emit a matrix row carrying that entry. That row is exactly the JSON thatget-jobsoutputs and that thetest-sweep-jobs consume withsecrets: inherit.\n\n**Why nothing else in the workflow catches this.** The maintainer's trust decision, per the PR description, is scoped to 'the approved merge revision' being executed by the benchmark jobs — but the entire reason process_changelog.pyandci_priority.pyare routed through.ci-priorityin the first place is to keep the *matrix-generation and priority-scoring control plane* on trusted code so an external label-approval doesn't also hand the contributor free rein over what configuration/image ends up in the secrets-bearing matrix. That is precisely the protection this bug removes for two of.ci-priority's three components (generate_sweep_configs.pyand the master configs;ci_priority.pyalone remains safely pinned since it's invoked with an explicit"/utils/ci_priority.py"path and--policy "/configs/ci-priority.yaml"). It is true that the get-jobs step itself has no secrets in its own env (persist-credentials: false, workflow-level permissions: contents: read), so this is not a direct secrets-exfiltration primitive from inside get-jobs— but that's not the threat this isolation exists to prevent. The threat is exactly what this bug enables: attacker-influenced data/code reaching the config-generation step that feedssecrets: inheritGPU jobs downstream.\n\n**Step-by-step PoC.** (1) An external contributor forks the repo and, in their PR branch, adds a new keyevil-keytoconfigs/nvidia-master.yamlwhoseimagefield points at a Docker image they control (crafted to exfiltrate the runner's inherited secrets or otherwise misbehave). (2) They add a corresponding entry inperf-changelog.yamlreferencingconfig-keys: [evil-key]. (3) A maintainer, believing they are approving only the exact reviewed source diff, applies full-sweep-fail-fast(one of the primary sweep labels) to the PR. (4)trusted-external-sweep.ymlvalidates the maintainer's permission and the head SHA, then dispatchese2e-tests.ymlonmainwithref=pull.merge_commit_sha, changelog-base-ref, changelog-head-ref. (5) In get-jobs, GITHUB_WORKSPACEis checked out to the merge commit (containing the attacker'sevil-keyentry and doctored master config);.ci-priorityholds the trustedprocess_changelog.py. (6) process_changelog.pyruns from.ci-prioritybut callsload_config_files(MASTER_CONFIGS)andsubprocess.run(["python3", GENERATE_SWEEPS_PY_SCRIPT, ...])with cwd defaulting toGITHUB_WORKSPACE— the attacker's checkout — soevil-keyis found 'valid' and a matrix row containing the attacker's image is generated. (7) That row is emitted viaGITHUB_OUTPUT, consumed by e.g. test-sweep-single-node, which runs benchmark-tmpl.ymlwithsecrets: inheritand the attacker-chosenimage. The maintainer never intended to approve a new config entry or image — only the reviewed diff — yet it reaches a secrets-bearing job.\n\n**Fix.** Pass cwd=os.environ["PRIORITY_ROOT"](or an equivalent absolute base) to bothsubprocess.runcalls inutils/process_changelog.py, and resolve MASTER_CONFIGS/GENERATE_SWEEPS_PY_SCRIPTrelative to that same trusted root before callingload_config_files/subprocess.run`, rather than relying on the ambient process cwd.

Comment on lines 472 to 480
labels = set(sc.get("labels", []))
draft = sc.get("draft", False)
is_pr = sc["event"] == "pull_request"
is_internal_pr = sc.get("head_repo", "SemiAnalysisAI/InferenceX") == (
"SemiAnalysisAI/InferenceX"
)
action = sc.get("action")

check_runs = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The exhaustive cross-product test in test_run_sweep_gating.py never varies the new head_repo/fork axis, so all 17666 generated scenarios stay on the internal repo and the newly-added is_internal_pr check in reference_gate() is only ever exercised as trivially-true. Only the single hand-written PR-sync-external-fork-defers-to-trusted-dispatch case covers the fork path, so an interaction bug between is_internal_pr and other action/label/draft axes wouldn't be caught by the exhaustive sweep. Consider adding head_repo as an 8th axis to pr_axes and bumping the asserted scenario count.

Extended reasoning...

What the bug is. This PR adds an is_internal_pr clause to reference_gate() in utils/changelog_gate_tests/test_run_sweep_gating.py, mirroring the new github.event.pull_request.head.repo.full_name == github.repository check the PR adds to run-sweep.yml's setup job. The module's docstring claims this harness "covers every distinct skip/run decision" and "cannot drift from production" because it exhaustively cross-checks reference_gate() against the real parsed if conditions over every combination of input axes. But _all_scenarios() builds pr_axes from only 7 axes — action, draft, labels, label.name, reuse_auth, check, msg — and never includes head_repo. Every constructed scenario dict omits the head_repo key entirely.

Code path. Both _ctx() (used by run_dag(), which evaluates the real production if strings) and reference_gate() fall back to sc.get('head_repo', 'SemiAnalysisAI/InferenceX') when the key is absent. Since _all_scenarios() never sets head_repo, every one of the 17666 generated scenarios defaults to the internal repo on both sides of the comparison, making is_internal_pr (and the mirrored production clause) vacuously True in every generated case. test_exhaustive_cross_product() even hard-asserts len(scenarios) == 17666, the same count as before the fork dimension existed, confirming no doubling from a fork axis.

Why existing coverage doesn't catch this. The PR does add one hand-written named case, PR-sync-external-fork-defers-to-trusted-dispatch, which does set head_repo: 'external/InferenceX' and correctly asserts ('success', 'success', 'SKIP'). That case is checked by test_gating_decision and test_named_cases_match_reference_spec, so the primary intended behavior (external fork PRs defer to the trusted dispatcher) is verified. However, the exhaustive cross-product sweep — the part of the suite explicitly designed to catch any divergence between reference_gate()'s encoding of intent and the real parsed YAML if string, across the full combinatorial space — never varies this dimension. If a future edit introduced a subtle interaction bug, e.g. is_internal_pr accidentally short-circuiting the [skip-sweep] check, or interacting incorrectly with unlabeled/labeled actions or draft state when the PR is external, the exhaustive test would not detect it, because it never generates a scenario where is_internal_pr is False.

Impact. This is a test-only coverage gap, not a runtime bug — is_internal_pr is a single straightforward AND clause and the production behavior for the fork case is separately verified by the named scenario and indirectly by the dedicated trusted-external-sweep.yml workflow. But it does mean the module's own stated guarantee ("covers every distinct skip/run decision... cannot drift from production") is no longer accurate for this dimension, and the gap is easy to overlook precisely because everything still passes.

Fix. Add head_repo as an 8th axis to pr_axes in _all_scenarios(), e.g. [SemiAnalysisAI/InferenceX, external/InferenceX], thread it into each generated scenario dict, and update the hard-coded assert len(scenarios) == 17666 to reflect the doubled PR-scenario count (17664 × 2 + 2 push cases = 35330).

Step-by-step proof.

  1. _all_scenarios() constructs pr_axes = itertools.product(actions, draft, label_cfgs, label_names, reuse_auth, check, msg) — 7 axes, no head_repo.
  2. Each scenario dict is built as {"event": "pull_request", "action": a, "draft": d, "labels": labs, "label_name": ln, "reuse_auth": r, "check": chk, "msg": msg} — again, no head_repo key.
  3. _ctx(sc) computes github.event.pull_request.head.repo.full_name via sc.get("head_repo", "SemiAnalysisAI/InferenceX") → always resolves to the internal repo for every generated scenario.
  4. reference_gate(sc) computes is_internal_pr the same way, via the same default → always True for every generated scenario.
  5. Therefore, for all 17664 generated PR scenarios, is_internal_pr is True on both the production-condition side (run_dag) and the reference side (reference_gate), so the new clause never actually gets exercised as False by the sweep — a regression where is_internal_pr breaks some other combination (e.g. is inverted, or drops the and) would still pass test_exhaustive_cross_product() today.
  6. Confirmed by the count assertion: assert len(scenarios) == 17666 is unchanged from its pre-PR value, proving no new dimension was folded into the product.

charxwu pushed a commit that referenced this pull request Aug 6, 2026
* ci: prototype trusted external sweep dispatch

Route maintainer-approved fork revisions through a main-branch workflow_dispatch so benchmark jobs receive required secrets while pinning the exact approved SHA.\n\n中文:通过 main 分支的 workflow_dispatch 调度维护者批准的 fork 提交,使基准测试任务在固定获批 SHA 的同时获取所需密钥。

* test: remove shallow workflow assertions

Keep the behavioral external-fork gating coverage while dropping string-based checks that could pass despite broken workflow semantics.\n\n中文:保留外部 fork 门控的行为测试,删除即使工作流语义损坏也可能通过的字符串断言。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

1 participant